aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/pages/og/[slug].jpg.ts
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-09-21 07:49:21 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-09-21 07:49:21 +0900
commit9328db082f2882fb8c2a37c4e73c39c7582e4709 (patch)
tree8d11342c5d94816db388949c69a9e45968c0412d /src/pages/og/[slug].jpg.ts
parent80862e66000d79846df87ca9db55f7980a53a83f (diff)
og/[slug].jpg.ts: OGP画像をPNGからJPEGに変更
Diffstat (limited to 'src/pages/og/[slug].jpg.ts')
-rw-r--r--src/pages/og/[slug].jpg.ts315
1 files changed, 315 insertions, 0 deletions
diff --git a/src/pages/og/[slug].jpg.ts b/src/pages/og/[slug].jpg.ts
new file mode 100644
index 0000000..f2015c4
--- /dev/null
+++ b/src/pages/og/[slug].jpg.ts
@@ -0,0 +1,315 @@
+import satori from "satori";
+import { Resvg } from "@resvg/resvg-js";
+import sharp from "sharp";
+import {
+ readFileSync,
+ existsSync,
+ readdirSync,
+ writeFileSync,
+ mkdirSync,
+} from "node:fs";
+import path from "node:path";
+
+const contentDir = "src/content/docs";
+
+const pages: Record<string, string> = {
+ index: "だれもがしあわせに暮らせるまちへ",
+ jisseki: "実績",
+ policy: "私の方針",
+ support: "ご支援",
+ contact: "コンタクト",
+ "whisper-to-ai-moji-okoshi":
+ "無料・超高精度のWhisperで文字起こしする方法(+α)",
+ "textlint-guide": "textlintで文章校正する方法",
+ "koubunsyo-kanri": "公文書管理の不正追及の軌跡",
+ "ijime-judai-jitai": "いじめ重大事態への対応の軌跡",
+ "fukushi-shisetsu-gyakutai": "障害者福祉施設における虐待通報対応の軌跡",
+ "aiki-kouen": "合気公園の軌跡",
+ "joutyo-koteikyu": "情緒固定級の軌跡",
+ "kajo-seigen-kanwa": "過剰な制限緩和の軌跡",
+ "saresio-kaihatu": "東京サレジオ学園北側開発問題の軌跡",
+ "vaccine-kyuusai-tekiseika": "ワクチン副反応救済制度の適正化の軌跡",
+ "dislexia-taiou": "ディスレクシア(読み書き障害)対応の軌跡",
+ "konan-eisei-kumiai": "湖南衛生組合の議員報酬・給与見直しの軌跡",
+ "ippan-situmon": "一般質問",
+ media: "メディア掲載",
+};
+
+function findMdxFiles(dir: string): string[] {
+ if (!existsSync(dir)) return [];
+ const files: string[] = [];
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) files.push(...findMdxFiles(full));
+ else if (entry.name.endsWith(".mdx")) files.push(full);
+ }
+ return files;
+}
+
+function readTitle(filePath: string): string | null {
+ try {
+ const content = readFileSync(filePath, "utf-8");
+ const m =
+ content.match(/^title:\s*"(.+?)"/m) ?? content.match(/^title:\s*(.+)/m);
+ return m?.[1] ?? null;
+ } catch {
+ return null;
+ }
+}
+
+export async function getStaticPaths() {
+ const slugs = new Set<string>();
+ // Manual pages
+ for (const s of Object.keys(pages)) slugs.add(s);
+ // Auto-discover ippan-situmon / iinkai pages
+ for (const dir of ["ippan-situmon", "iinkai"]) {
+ for (const f of findMdxFiles(path.join(contentDir, dir))) {
+ const s = f
+ .replace(/\\/g, "/")
+ .replace(contentDir + "/", "")
+ .replace(/\.mdx$/, "")
+ .replace(/\/index$/, "");
+ if (s) slugs.add(s);
+ }
+ }
+ return [...slugs]
+ .filter((s) => s.length > 0)
+ .map((slug) => ({ params: { slug: slug.replace(/\//g, "--") } }));
+}
+
+const fontBuffer = readFileSync("node_modules/.noto-sans-jp.otf");
+const faceiconBuffer = readFileSync("public/img/faceicon.jpg");
+const faceiconBase64 = `data:image/jpeg;base64,${faceiconBuffer.toString("base64")}`;
+
+// Generate star elements for space-like background
+const stars = [
+ { x: 80, y: 40, r: 2, o: 0.8 },
+ { x: 200, y: 90, r: 1.5, o: 0.6 },
+ { x: 350, y: 30, r: 2.5, o: 0.9 },
+ { x: 500, y: 80, r: 1, o: 0.5 },
+ { x: 650, y: 50, r: 2, o: 0.7 },
+ { x: 800, y: 100, r: 1.5, o: 0.6 },
+ { x: 950, y: 40, r: 3, o: 0.85 },
+ { x: 1100, y: 70, r: 1, o: 0.5 },
+ { x: 1150, y: 120, r: 2, o: 0.7 },
+ { x: 150, y: 160, r: 1, o: 0.4 },
+ { x: 450, y: 140, r: 2, o: 0.5 },
+ { x: 700, y: 160, r: 1.5, o: 0.45 },
+ { x: 1000, y: 150, r: 2.5, o: 0.55 },
+ { x: 50, y: 500, r: 1.5, o: 0.4 },
+ { x: 300, y: 550, r: 2, o: 0.5 },
+ { x: 600, y: 520, r: 1, o: 0.35 },
+ { x: 850, y: 570, r: 2.5, o: 0.5 },
+ { x: 1050, y: 540, r: 1, o: 0.4 },
+ { x: 1150, y: 580, r: 1.5, o: 0.45 },
+ { x: 120, y: 300, r: 1, o: 0.3 },
+ { x: 1080, y: 280, r: 1.5, o: 0.35 },
+].map((s, i) => ({
+ type: "div" as const,
+ props: {
+ key: `star-${i}`,
+ style: {
+ position: "absolute" as const,
+ left: `${s.x}px`,
+ top: `${s.y}px`,
+ width: `${s.r * 2}px`,
+ height: `${s.r * 2}px`,
+ borderRadius: "50%",
+ background: `rgba(255,255,255,${s.o})`,
+ boxShadow: `0 0 ${s.r * 3}px rgba(255,255,255,${s.o * 0.4})`,
+ },
+ },
+}));
+
+export async function GET({ params }: { params: { slug: string } }) {
+ const slug = params.slug;
+ // まずそのままマッチ(koubunsyo-kanri などの手動ページ)
+ let title = pages[slug];
+ if (!title) {
+ const fileSlug = slug.replace(/--/g, "/");
+ title = pages[fileSlug];
+ if (!title) {
+ title =
+ readTitle(path.join(contentDir, fileSlug + ".mdx")) ??
+ readTitle(path.join(contentDir, fileSlug, "index.mdx")) ??
+ "小平市議 安竹洋平 公式サイト";
+ }
+ }
+ // 一般質問の月例会インデックスページ: 年度・月を補完
+ const mm = slug.match(/^ippan-situmon--(r\d+d)--(\d+gatu)$/);
+ if (mm) {
+ const reiwa = mm[1].replace(
+ /^r(\d+)d$/,
+ (_, n) => `令和${n === "1" ? "元" : n}年`,
+ );
+ const month = mm[2].replace(/(\d+)gatu/, "$1月");
+ title = `${reiwa}${month}定例会`;
+ }
+
+ // Check cache: if title hash matches, return cached JPEG from node_modules/.og-cache
+ const cacheDir = path.join(process.cwd(), "node_modules", ".og-cache");
+ const cacheFile = path.join(cacheDir, `${slug}.jpg`);
+ const cacheJsonPath = path.join(cacheDir, ".og-cache.json");
+
+ let cache: Record<string, string> = {};
+ if (existsSync(cacheJsonPath)) {
+ cache = JSON.parse(readFileSync(cacheJsonPath, "utf-8"));
+ }
+
+ const { createHash } = await import("node:crypto");
+ const titleHash = createHash("md5").update(title).digest("hex");
+
+ if (existsSync(cacheFile) && cache[slug] === titleHash) {
+ const cachedBuffer = readFileSync(cacheFile);
+ return new Response(cachedBuffer, {
+ headers: {
+ "Content-Type": "image/jpeg",
+ "Cache-Control": "public, max-age=31536000, immutable",
+ },
+ });
+ }
+
+ const svg = await satori(
+ {
+ type: "div",
+ props: {
+ style: {
+ display: "flex",
+ width: "1200px",
+ height: "630px",
+ background:
+ "linear-gradient(135deg, #0f0d2e 0%, #1e1b4b 30%, #3730a3 60%, #4f46e5 100%)",
+ color: "#ffffff",
+ fontFamily: "sans-serif",
+ padding: "64px 80px",
+ alignItems: "center",
+ gap: "56px",
+ position: "relative",
+ overflow: "hidden",
+ },
+ children: [
+ ...stars,
+ {
+ type: "img",
+ props: {
+ src: faceiconBase64,
+ width: 180,
+ height: 180,
+ style: {
+ borderRadius: "50%",
+ border: "3px solid rgba(255,255,255,0.25)",
+ flexShrink: "0",
+ position: "relative",
+ },
+ },
+ },
+ {
+ type: "div",
+ props: {
+ style: {
+ display: "flex",
+ flexDirection: "column",
+ gap: "20px",
+ flex: "1",
+ position: "relative",
+ },
+ children: [
+ {
+ type: "div",
+ props: {
+ style: {
+ fontSize: "64px",
+ fontWeight: "700",
+ lineHeight: "1.2",
+ letterSpacing: "-0.02em",
+ },
+ children: title,
+ },
+ },
+ {
+ type: "div",
+ props: {
+ style: {
+ display: "flex",
+ gap: "14px",
+ fontSize: "32px",
+ fontWeight: "500",
+ color: "#a5b4fc",
+ },
+ children: [
+ { type: "div", props: { children: "だれもが" } },
+ {
+ type: "div",
+ props: {
+ style: { color: "#c7d2fe" },
+ children: "しあわせに",
+ },
+ },
+ {
+ type: "div",
+ props: {
+ style: { color: "#e0e7ff" },
+ children: "暮らせるまちへ",
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: "div",
+ props: {
+ style: {
+ display: "flex",
+ gap: "16px",
+ fontSize: "26px",
+ fontWeight: "400",
+ color: "rgba(255,255,255,0.45)",
+ paddingTop: "12px",
+ borderTop: "1px solid rgba(255,255,255,0.1)",
+ },
+ children: [
+ { type: "div", props: { children: "小平市議 安竹洋平" } },
+ {
+ type: "div",
+ props: {
+ style: { opacity: "0.6" },
+ children: "yasutakeyohei.com",
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ } as any,
+ {
+ width: 1200,
+ height: 630,
+ fonts: [
+ { name: "sans-serif", data: fontBuffer, weight: 400, style: "normal" },
+ ],
+ },
+ );
+
+ const resvg = new Resvg(svg, { fitTo: { mode: "width", value: 1200 } });
+ const pngBuffer = resvg.render().asPng();
+ // OGP は JPEG で出力する(PNGだと1枚300KBを超えるため、JPEGで約1/5にする)
+ const jpgBuffer = await sharp(pngBuffer).jpeg({ quality: 88 }).toBuffer();
+
+ // Save to cache in node_modules/.og-cache (persists across builds)
+ if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
+ writeFileSync(cacheFile, jpgBuffer);
+ cache[slug] = titleHash;
+ writeFileSync(cacheJsonPath, JSON.stringify(cache, null, 2));
+
+ return new Response(jpgBuffer, {
+ headers: {
+ "Content-Type": "image/jpeg",
+ "Cache-Control": "public, max-age=31536000, immutable",
+ },
+ });
+}